<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">

    <title>3D Ripple — Alien.js</title>

    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono">
    <link rel="stylesheet" href="../assets/css/style.css">

    <script type="module">
        import { AssetLoader, Color, ColorManagement, DirectionalLight, Group, Interface, LinearFilter, LinearSRGBColorSpace, Mesh, MeshStandardMaterial, PanelItem, PerspectiveCamera, PlaneGeometry, Scene, Texture, UI, Vector2, WebGLRenderer, WireframeOptions, clearTween, delayedCall, getKeyByValue, getViewSize, ticker } from '../../build/alien.three.js';

        class GridCanvas extends Interface {
            constructor() {
                super(null, 'canvas');

                this.initCanvas();
                this.initGrid();
            }

            initCanvas() {
                this.context = this.element.getContext('2d');
            }

            initGrid() {
                this.grid = {
                    fillStyle: '#060606',
                    strokeStyle: '#fff'
                };
            }

            drawGrid() {
                this.context.fillStyle = this.grid.fillStyle;
                this.context.strokeStyle = this.grid.strokeStyle;

                this.context.fillRect(0, 0, this.width, this.height);
                this.context.beginPath();

                for (let x = 0; x < this.width; x += this.width / 10) {
                    this.context.moveTo(x, 0);
                    this.context.lineTo(x, this.height);
                }

                for (let y = 0; y < this.height; y += this.height / 10) {
                    this.context.moveTo(0, y);
                    this.context.lineTo(this.width, y);
                }

                this.context.stroke();
            }

            // Public methods

            resize = (width, height, dpr) => {
                this.width = width;
                this.height = height;

                this.element.width = Math.round(this.width * dpr);
                this.element.height = Math.round(this.height * dpr);
                this.context.scale(dpr, dpr);

                this.update();
            };

            update = () => {
                this.context.clearRect(0, 0, this.element.width, this.element.height);

                this.drawGrid();
            };
        }

        class SceneView extends Group {
            constructor() {
                super();

                this.canvas = {};
                this.context = {};
                this.texture = {};
                this.image = {};
                this.ripples = [];
                this.multiplier = 1;
                this.needsUpdate = false;
                this.isLoaded = false;
                this.visible = false;

                this.initCanvas();
                this.initMesh();
            }

            initCanvas() {
                this.grid = new GridCanvas();
                this.createCanvas('displace');
                this.createCanvas('ripple');
            }

            initMesh() {
                this.material = new MeshStandardMaterial({
                    // wireframe: true,
                    displacementScale: 0.3,
                    depthTest: false,
                    depthWrite: false
                });

                this.mesh = new Mesh(new PlaneGeometry(1, 1, 64, 64), this.material);
                this.mesh.frustumCulled = false;
                this.add(this.mesh);
            }

            async initImages() {
                const now = Date.now();
                const svgHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><filter id="ripple_${now}"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><g fill="none" filter="url(#ripple_${now})"><rect width="100%" height="100%"></rect><circle cx="512" cy="512" r="256" stroke="#fff" stroke-width="64"/></g></svg>`;

                await Promise.all([
                    this.loadImage('ripple', `data:image/svg+xml;base64,${btoa(svgHTML)}`)
                ]);

                this.isLoaded = true;

                this.resize();
            }

            async loadImage(name, path) {
                const image = await WorldController.loadImage(path);

                this.image[name] = image;

                return image;
            }

            createCanvas(name) {
                this.canvas[name] = document.createElement('canvas');
                this.context[name] = this.canvas[name].getContext('2d');
            }

            resizeCanvas(name, width, height, dpr) {
                this.canvas[name].width = Math.round(width * dpr);
                this.canvas[name].height = Math.round(height * dpr);
            }

            updateRipples() {
                const calc = (frame, min, max, total) => max * Math.sin(frame / total * (Math.PI / 2)) + min;
                let offset = 0;

                this.ripples.forEach((ripple, i) => {
                    if (ripple.frame++ >= 0) {
                        const frame = ripple.frame;

                        ripple.x = ripple.direction > 0 ? -0.5 + calc(frame, 0, 1, 260) : 1.5 - calc(frame, 0, 1, 260);
                        ripple.scale = 1.5 + calc(frame, 0, 12 * this.multiplier, 260);
                        ripple.alpha = frame > 200 ? 1 - calc(frame - 200, 0, 1, 60) : 1;

                        if (frame >= 260) {
                            this.ripples.splice(i - offset++, 1);

                            if (!this.ripples.length) {
                                this.needsUpdate = false;
                            }
                        }
                    }
                });
            }

            drawDisplace() {
                this.context.displace.fillRect(0, 0, this.canvas.displace.width, this.canvas.displace.height);

                this.updateRipples();

                this.ripples.forEach(ripple => {
                    const width = this.canvas.ripple.width * ripple.scale;
                    const height = this.canvas.ripple.height * ripple.scale;

                    this.context.displace.save();
                    this.context.displace.translate(this.canvas.displace.width * ripple.x, this.canvas.displace.height * ripple.y);
                    this.context.displace.transform(1, ripple.skew, 0, 1 + ripple.random, 0, 0);
                    this.context.displace.globalAlpha = ripple.alpha;
                    this.context.displace.drawImage(this.canvas.ripple, -width / 2, -height / 2, width, height);
                    this.context.displace.restore();
                });
            }

            // Public methods

            resize = () => {
                const width = document.documentElement.clientWidth;
                const height = document.documentElement.clientHeight;
                const dpr = window.devicePixelRatio;

                if (this.texture.grid) {
                    this.texture.grid.dispose();
                }

                if (this.texture.displace) {
                    this.texture.displace.dispose();
                }

                this.grid.resize(width, height, dpr);
                this.resizeCanvas('displace', width, height, dpr);
                this.resizeCanvas('ripple', 256, 256, dpr);

                this.texture.grid = new Texture(this.grid.element);
                this.texture.grid.minFilter = LinearFilter;
                this.texture.grid.generateMipmaps = false;
                this.texture.grid.needsUpdate = true;
                this.texture.displace = new Texture(this.canvas.displace);
                this.texture.displace.minFilter = LinearFilter;
                this.texture.displace.generateMipmaps = false;
                this.texture.displace.needsUpdate = true;

                this.material.map = this.texture.grid;
                // this.material.map = this.texture.displace;
                this.material.displacementMap = this.texture.displace;

                if (this.isLoaded) {
                    // Draws from a canvas are faster
                    this.context.ripple.drawImage(this.image.ripple, 0, 0, this.canvas.ripple.width, this.canvas.ripple.height);

                    this.update();
                }

                if (width < height) {
                    this.multiplier = 0.5;
                } else {
                    this.multiplier = 1;
                }

                this.material.displacementScale = 5 * this.multiplier;

                const { getViewSize } = WorldController;

                const { x, y } = getViewSize();
                this.mesh.scale.set(x, y, 1);
            };

            update = () => {
                if (!this.visible) {
                    return;
                }

                this.drawDisplace();

                this.texture.displace.needsUpdate = true;
            };

            addRipple = (direction = 1, skew = 0, frame = 0) => {
                this.ripples.push({
                    direction,
                    x: direction > 0 ? -0.5 : 1.5,
                    y: 0.5,
                    skew: 0.5 * Math.random() * skew,
                    scale: 1.5,
                    alpha: 1,
                    frame,
                    random: Math.random()
                });
            };

            wave = direction => {
                this.addRipple(direction, 1, 0);
                this.addRipple(direction, -1, 10 * Math.random() + 5);

                this.needsUpdate = true;
            };
        }

        class SceneController {
            static init(view) {
                this.view = view;

                this.mouse = new Vector2();
                this.last = new Vector2();
                this.delta = new Vector2();
                this.mouse.set(document.documentElement.clientWidth / 2, document.documentElement.clientHeight / 2);
                this.last.copy(this.mouse);

                this.timeout = false;

                this.addListeners();
                this.constantWaving();
            }

            static addListeners() {
                document.addEventListener('visibilitychange', this.onVisibility);
                window.addEventListener('pointermove', this.onPointerMove);
            }

            // Event handlers

            static onVisibility = () => {
                if (document.hidden) {
                    clearTween(this.constant);
                } else {
                    this.constantWaving();
                }
            };

            static onPointerMove = ({ clientX, clientY }) => {
                if (!this.view.visible) {
                    return;
                }

                this.mouse.set(clientX, clientY);
                this.delta.subVectors(this.mouse, this.last);
                this.last.copy(this.mouse);

                if (!this.timeout) {
                    this.timeout = true;

                    this.view.wave(Math.sign(this.delta.x));

                    delayedCall(2500, () => {
                        this.timeout = false;
                    });
                }
            };

            // Public methods

            static resize = () => {
                this.view.resize();
            };

            static update = () => {
                if (this.view.needsUpdate) {
                    this.view.update();
                }
            };

            static constantWaving = () => {
                this.view.wave(1);

                this.constant = delayedCall(1000 + Math.random() * 1000, this.constantWaving);
            };

            static animateIn = () => {
                this.view.visible = true;
            };

            static ready = () => this.view.initImages();
        }

        class PanelController {
            static init(view, ui) {
                this.view = view;
                this.ui = ui;

                this.initPanel();
            }

            static initPanel() {
                const { mesh } = this.view;

                const currentMaterialMap = mesh.material.map;

                const items = [
                    {
                        name: 'FPS'
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'list',
                        list: WireframeOptions,
                        value: getKeyByValue(WireframeOptions, mesh.material.wireframe),
                        callback: value => {
                            mesh.material.wireframe = WireframeOptions[value];

                            if (mesh.material.wireframe) {
                                mesh.material.map = null;
                            } else {
                                mesh.material.map = currentMaterialMap;
                            }

                            mesh.material.needsUpdate = true;
                        }
                    }
                ];

                items.forEach(data => {
                    this.ui.addPanel(new PanelItem(data));
                });
            }
        }

        class RenderManager {
            static init(renderer, scene, camera) {
                this.renderer = renderer;
                this.scene = scene;
                this.camera = camera;
            }

            // Public methods

            static resize = (width, height, dpr) => {
                this.renderer.setPixelRatio(dpr);
                this.renderer.setSize(width, height);
            };

            static update = () => {
                this.renderer.render(this.scene, this.camera);
            };
        }

        class WorldController {
            static init() {
                this.initWorld();
                this.initLights();
                this.initLoaders();

                this.addListeners();
            }

            static initWorld() {
                this.renderer = new WebGLRenderer({
                    powerPreference: 'high-performance',
                    antialias: true
                });

                // Output canvas
                this.element = this.renderer.domElement;

                // Disable color management
                ColorManagement.enabled = false;
                this.renderer.outputColorSpace = LinearSRGBColorSpace;

                // 3D scene
                this.scene = new Scene();
                this.scene.background = new Color(0x060606);
                this.camera = new PerspectiveCamera(25);
                this.camera.near = 0.1;
                this.camera.far = 1000;
                this.camera.position.z = 100;
                this.camera.lookAt(this.scene.position);

                // Global uniforms
                this.resolution = { value: new Vector2() };
                this.texelSize = { value: new Vector2() };
                this.aspect = { value: 1 };
                this.time = { value: 0 };
                this.frame = { value: 0 };
            }

            static initLights() {
                const light = new DirectionalLight(0xffffff, 3);
                light.position.set(1, 1, 1);
                this.scene.add(light);
            }

            static initLoaders() {
                this.assetLoader = new AssetLoader();
            }

            static addListeners() {
                this.renderer.domElement.addEventListener('touchstart', this.onTouchStart);
            }

            // Event handlers

            static onTouchStart = e => {
                e.preventDefault();
            };

            // Public methods

            static resize = (width, height, dpr) => {
                this.camera.aspect = width / height;
                this.camera.updateProjectionMatrix();

                width = Math.round(width * dpr);
                height = Math.round(height * dpr);

                this.resolution.value.set(width, height);
                this.texelSize.value.set(1 / width, 1 / height);
                this.aspect.value = width / height;
            };

            static update = (time, delta, frame) => {
                this.time.value = time;
                this.frame.value = frame;
            };

            // Global handlers

            static loadImage = path => this.assetLoader.loadImage(path);

            static getViewSize = object => getViewSize(this.camera, object);
        }

        class App {
            static async init() {
                this.initWorld();
                this.initViews();
                this.initControllers();

                this.addListeners();
                this.onResize();

                await SceneController.ready();
                SceneController.animateIn();

                this.initPanel();
            }

            static initWorld() {
                WorldController.init();
                document.body.appendChild(WorldController.element);
            }

            static initViews() {
                this.view = new SceneView();
                WorldController.scene.add(this.view);

                this.ui = new UI({
                    fps: true,
                    thumbnail: {
                        image: this.view.canvas.displace,
                        position: 'bl' // tl, bl, br, tr
                    }
                });
                this.ui.animateIn();
                document.body.appendChild(this.ui.element);
            }

            static initControllers() {
                const { renderer, scene, camera } = WorldController;

                SceneController.init(this.view);
                RenderManager.init(renderer, scene, camera);
            }

            static initPanel() {
                PanelController.init(this.view, this.ui);
            }

            static addListeners() {
                window.addEventListener('resize', this.onResize);
                ticker.add(this.onUpdate);
                ticker.start();
            }

            // Event handlers

            static onResize = () => {
                const width = document.documentElement.clientWidth;
                const height = document.documentElement.clientHeight;
                const dpr = window.devicePixelRatio;

                WorldController.resize(width, height, dpr);
                SceneController.resize();
                RenderManager.resize(width, height, dpr);
            };

            static onUpdate = (time, delta, frame) => {
                WorldController.update(time, delta, frame);
                SceneController.update();
                RenderManager.update(time, delta, frame);
                this.ui.update();
            };
        }

        App.init();
    </script>
</head>
<body>
</body>
</html>
